在之前我有寫一篇關於資料庫的ACID分享RDBMS資料庫基本原則
假如我們系統是一個多執行續高併發系統也要注意Atomic不然會造成資料會有Data Racing導致bug產生..
本文使用範例在我github上CAS_Lock有興趣的人可以參考.
本篇同步發布在我的Blog 高併發系統系列-使用lock & Interlocked CAS(compare and swap)
我是我們公司擔任技術面試官之一,假如面試者說他實作過高併發系統。
我就會先問以下問題來辨別是否要更深入面試高併發系統相關問題.
下面Sample Code是這樣.
我用Task
假裝高併發,下面有一個Member
類別預設給100000元的Balance
有一個UpdateBalance
每呼叫一次就扣10元,我透過For
跑10000次
理論上我預期在跑完下面顯示Balance會員餘額是0 (因為 10000*10 = 100000).
class Program
{
static void Main(string[] args)
{
Member member = new Member() { Balance = 100000 };
List<Task> tasks = new List<Task>();
for (int i = 0; i < 10000; i++)
{
tasks.Add(Task.Run(() => member.UpdateBalance()));
}
Task.WaitAll(tasks.ToArray());
Console.WriteLine($"member remaining balance is {member.Balance}");
Console.ReadKey();
}
}
public class Member {
public decimal Balance { get; set; }
public void UpdateBalance()
{
Balance -= 10;
}
}
但執行後蠻常會是不等於0!!
這時我會問面試者兩個問題
如果知道的人可以跳到CAS部分,如果不知原因我下面會跟大家分享
這個問題牽扯到Thread是如何對於變數操作的,Thread在操作變數之前會把資料複製一份進Thread Context中在操作我們要步驟
所以在Balance -= 10;
這段程式碼會拆成下面動作
因為以上步驟,假如同一時間有兩個不同的Thread取到的Balance都是1000,並個別對於Balance減10元,我們原本預期這是兩個操作(預期資料為980)
但因為取的瞬間都是1000-10=990把數值放回變數中就導致少扣一個10元...
概念如下圖.
知道原因了要解決就簡單了.
因為這段程式碼遇到Data Raceing在同一個時間有兩個不同的Thread對於資料競爭
如果要避免競爭lock是一個比較方便的方式,他可以保證一瞬間只有一個Thread(Session)來執行某段程式碼(通常會放在異動資料那部分)來保證Isolation.
下面是使用lock版本的程式碼,能看到我在Balance -= 10;
這一段使用lock來確保每一個瞬間只有一個Thread可以異動資料,其他的Thread需要blocking等他完成
class Program
{
static object _lock = new object();
static void Main(string[] args)
{
Member member = new Member() { Balance = 100000 };
List<Task> tasks = new List<Task>();
for (int i = 0; i < 10000; i++)
{
tasks.Add(Task.Run(() => member.UpdateBalance()));
}
Task.WaitAll(tasks.ToArray());
Console.WriteLine($"member remaining balance is {member.Balance}");
Console.ReadKey();
}
}
public class Member {
//here
object _lock = new object();
public decimal Balance { get; set; }
public void UpdateBalance()
{
lock (_lock)
{
Balance -= 10;
}
}
}
使用lock之後能發現不管執行幾次資料都會如我們預期顯示.
使用lock執行概念圖如下.
在同一時間我們會把要執行程式 利用一個類似保護網的方式,確保同一時間只有一個Thread來做操作.
Thread2取得lock在操作Thread1就必須等待Thread2執行完,在取值=>改值..等等動作
只是使用lock會降低些許吞吐量(但資料正確性是最重要的),所以要注意使用lock範圍大小
CAS是利用compare and swap來確保資料Atomic.
因為CAS可以取得數值記憶體空間來比較給值並且他也是一條CPU原語具有原子性 cpu 硬體同步原語
前面有說過在Balance -= 10;
這段程式碼會拆成下面動作
balance -= 10;
會拆解成類似下面動作
int temp = balance;
temp = temp -10;
balance = temp;
假如在取balance(附值給temp)跟把值重新寫入balance中間有其他Thread來操作,就會造成所謂Data Racing,因為對於我們來說上面後兩部有不可分割性(Atomic).
而這時候我們就可以使用CAS算法來幫我們解決問題,在C#如果我們想要達成變數修改的Atomic可以透過Interlocked
類別
在C#中我們可以使用Interlocked這個類別
對於Int
,Long
相關操作都有封裝成method.
下面這段虛擬碼解釋balance -= 10;
CAS中類似下面效果
//original
int temp = balance;
temp = temp -10;
balance = temp;
//CAS
int oldValue = balance;
int newValue = oldValue - 1;
//compare value source and set
Interlocked.CompareExchange(ref balance,newValue,oldValue);
//Interlocked.CompareExchange類似下面做法,但具有Atomic
//if(ref balance == oldValue){
// balance = newValue;
//}
主要是把compare value source and set這段包成一個不可分割區段達到Atomic.
> 我上面用ref
來表示從記憶體位置取得balance
原始資料
Exchange
:把值改成另一個數值 具有AtomicDecrement
:把數值-- 具有AtomicIncrement
:把數值++ 具有Atomic除了上面我們還可以針對Reference Type做Atomic有興趣的人在自行了解
class Program
{
static object _lock = new object();
static void Main(string[] args)
{
Stopwatch sw = new Stopwatch();
int balanceValue = 10000000;
Member member = new Member() { Balance = balanceValue };
List<Task> tasks = new List<Task>();
sw.Start();
for (int i = 0; i < 1000000; i++)
{
tasks.Add(Task.Run(() => member.UpdateBalance()));
}
Task.WaitAll(tasks.ToArray());
sw.Stop();
Console.WriteLine("Lock Version");
Console.WriteLine($"member remaining balance is {member.Balance}");
Console.WriteLine($"Exec Time Cost : {sw.ElapsedMilliseconds}");
tasks.Clear();
member.Balance = balanceValue;
sw.Restart();
for (int i = 0; i < 1000000; i++)
{
tasks.Add(Task.Run(() => member.UpdateBalanceByInterlock()));
}
Task.WaitAll(tasks.ToArray());
sw.Stop();
Console.WriteLine("InterLocked Version:");
Console.WriteLine($"member remaining balance is {member.Balance}");
Console.WriteLine($"Exec Time Cost : {sw.ElapsedMilliseconds}");
Console.ReadKey();
}
}
public class Member {
object _lock = new object();
public int Balance { get; set; }
public void UpdateBalance()
{
lock (_lock)
{
Balance -= 10;
}
}
public void UpdateBalanceByInterlock()
{
int val = 0;
Balance = Interlocked.Exchange(ref val, Balance -= 10);
}
}
下圖是比較兩者執行時間還有併發計算數值,能發現數值兩個都正確計算出0,但Interlocked執行時間少於lock版本.
Interlocked效率會比較高是因為block會造成Thread的blocking等待浪費,但Interlocked核心概念是在這段話Atomic取得資料跟原值比較(如果資料還沒改就把值修改進Memory中)
所以效率就會比lock好很多
在有些情境適合使用Lock(例如許多操作需要有一致的Atomic)就比較適合.
Interlocked適合用在對於數值的Atomic.
在多執行緒的世界中要顧慮的點真的很多,稍有不甚就會造成很多錯誤.
因為多執行緒有許多地方需要注意,不然執行效率會不如單執行緒.
我慢慢可以理解為什麼Redis,Node.js一開始要使用sigel Thread來運作了...